返回文章列表

PostgreSQL Streaming Replication、读副本与同步复制

从 WAL Sender、WAL Receiver、Startup Process、LSN、复制槽、异步复制、同步复制、Recovery Conflict、Promotion 与读副本一致性出发,理解 PostgreSQL 物理流复制。

第 21 章:Streaming Replication、读副本与同步复制

1. 本章定位

物理流复制把 Primary 产生的 WAL 持续传输到 Standby,由 Standby 重放 WAL,形成与 Primary 接近实时的物理副本。它同时承担三类任务:

  • 为 Primary 故障后的接管提供候选节点;
  • 为最终一致或受控一致的只读流量提供读副本;
  • 为跨机房灾备、备份卸载和级联复制提供基础。

本章建立在第 13 章 WAL、Checkpoint 与 Crash Recovery,第 16 章 Go 与 pgx,以及第 20 章 Backup、PITR 和 Timeline 之上。下一章将讨论逻辑复制和 CDC,第 23 章再展开 Patroni、分布式一致性、Fencing 和自动故障转移。

本章不把“有一个副本”误认为“已经高可用”。复制只解决数据副本问题;节点选主、脑裂防护、客户端路由、旧 Primary 隔离和恢复验证仍需要独立设计。


2. 可验证的学习目标

完成本章后,你应当能够:

  1. 从事务提交开始,解释 WAL Sender、WAL Receiver 和 Startup Process 的完整路径。
  2. 使用 pg_stat_replicationpg_stat_wal_receiver 和 LSN 函数区分发送、写入、刷盘与重放延迟。
  3. 判断异步复制在一次 Failover 中可能丢失哪些已确认事务。
  4. 根据业务 RPO、提交延迟和可用性要求选择 remote_writeonremote_applylocal
  5. 配置并解释 FIRST 优先级同步复制与 ANY Quorum 同步复制。
  6. 复现 WAL Replay 暂停、Recovery Conflict 和 Slot WAL 堆积。
  7. 解释 hot_standby_feedback 如何减少查询取消,又为何可能导致 Primary 膨胀。
  8. 设计带超时和 Primary 回退的 LSN Read-After-Write。
  9. 执行一次安全的 Promotion,并使用 pg_rewind 将旧 Primary 重新加入为 Standby。
  10. 为读副本延迟、同步复制阻塞和 pg_wal 空间增长建立监控与 Runbook。

3. 核心术语

中文名称英文名称准确定义容易混淆的概念所属层次
物理复制Physical Replication按整个数据库集群的物理 WAL 变化复制数据页状态逻辑复制、表级复制存储/复制
主库Primary接受正常读写并生成 WAL 的节点当前路由目标不一定就是 Primary拓扑
备库Standby持续恢复并重放上游 WAL 的节点Backup 不是 Standby拓扑
热备Hot Standby在恢复期间允许执行只读查询的 Standby热备仍然可能取消查询查询/复制
基础备份Base Backup创建 Standby 或进行物理恢复的集群级文件基线pg_dump 是逻辑备份备份
WAL SenderWAL Sender上游为复制连接发送 WAL 的进程普通客户端 Backend进程
WAL ReceiverWAL ReceiverStandby 接收并写入 WAL 的进程Startup Process 不负责网络接收进程
Startup ProcessStartup Process在恢复状态下读取并应用 WAL 的进程Checkpointer、WAL Receiver恢复
LSNLog Sequence NumberWAL 字节流中的逻辑位置时间戳、事务 IDWAL
sent_lsnSent LSNWAL Sender 已发送的最高位置不表示 Standby 已收到网络
write_lsnWrite LSNStandby 已写入操作系统的最高位置不一定持久化Standby
flush_lsnFlush LSNStandby 已持久化的最高位置不等于已经可查询持久性
replay_lsnReplay LSNStandby 已应用到数据页的最高位置不保证旧 Snapshot 能看到可见性
复制槽Replication Slot记录消费者仍需要哪些 WAL 或旧元组的持久状态不是复制连接本身保留机制
restart_lsnRestart LSNSlot 消费者仍可能需要的最旧 WAL 位置不是当前 Replay LSNSlot
TimelineTimeline一条独立 WAL 历史分支LSN 数值本身不能唯一表示分支恢复
异步复制Async ReplicationPrimary 提交不等待 Standby 确认不等于 Standby 一定很慢提交语义
同步复制Sync Replication提交等待指定 Standby 达到某个 WAL 阶段不等于备份或绝对零损失提交语义
Recovery Conflict恢复冲突Standby 查询与必须应用的 WAL 操作发生冲突不完全等同于普通锁等待并发
Promotion提升结束恢复,将 Standby 转换为可写 PrimaryPromotion 不自动完成 Fencing高可用
Switchover计划切换原 Primary 可控时执行的角色切换Failover 是故障下的切换高可用
Failover故障转移原 Primary 不可用时提升候选 StandbyFailback 不是简单反向切换高可用
Stale Read陈旧读查询未看到已经在 Primary 提交的数据也可能由旧 Snapshot 或缓存造成一致性
Read-After-Write写后读一致性写成功后,同一因果链中的读取必须看到该写入不必意味着全局线性一致应用语义

4. 整体心智模型

PostgreSQL Streaming Replication、读副本与同步复制 flow 1

4.1 数据流

  1. Primary Backend 修改缓冲页,同时生成 WAL。
  2. 提交记录进入 WAL,并根据 synchronous_commit 在 Primary 刷盘。
  3. WAL Sender 将 WAL 流发送给 Standby。
  4. WAL Receiver 接收 WAL,先写入 Standby 操作系统,再持久化到 pg_wal
  5. Startup Process 读取 WAL,修改 Standby 的缓冲页和数据文件。
  6. Hot Standby 查询通过新的 MVCC Snapshot 读取已经 Replay 的状态。

4.2 控制流

  • primary_conninfo 决定 Standby 连接哪个上游。
  • primary_slot_name 决定是否使用持久 Slot。
  • synchronous_standby_names 决定哪些连接可能承担同步确认。
  • synchronous_commit 决定当前事务等待到哪一个阶段。
  • max_standby_streaming_delay 决定 Replay 最多为冲突查询等待多久。
  • Promotion 信号使 Standby 结束恢复并创建新的 Timeline。

4.3 状态变化

典型 Standby 状态为:

启动
→ 从 Archive / 本地 pg_wal 恢复
→ 连接上游
→ catchup
→ streaming
→ 持续 receive/write/flush/replay
→ promotion requested
→ replay 可用 WAL
→ 创建新 Timeline
→ normal primary

Standby 启动时存在 standby.signal,它会依次尝试 Archive、本地 pg_wal 和 Streaming Replication,直到停止或被提升。pg_promote()pg_ctl promote 会结束 Standby 模式。(PostgreSQL)

4.4 故障路径

网络断开
├─ Slot/Archive 中仍有 WAL → 重连后追赶
└─ 所需 WAL 已回收 → Standby 无法继续,需要 Archive 或重建

同步 Standby 失联
├─ 所需同步数量仍满足 → 继续提交
└─ 所需同步数量不足 → 提交等待,锁和连接逐步堆积

Primary 故障
├─ 成功 Fence 原 Primary
├─ 选择最合适 Standby
├─ Promotion
├─ 客户端重连
└─ 旧 Primary 使用 pg_rewind 或 Base Backup 重建

未 Fence 就 Promotion
└─ 两个可写节点 → Split Brain → Timeline 分叉和业务冲突

5. 使用方式

5.1 建立最小 Primary/Standby

以下值只是实验样例,不是生产环境通用参数。生产取值必须结合 WAL 生成速率、最长故障时间、磁盘余量、网络带宽、RPO 和重建时间计算。

Primary 的主要参数:

listen_addresses = '*'

wal_level = replica
max_wal_senders = 10
max_replication_slots = 10

# 只是额外保留的最低 WAL 量,不是严格上限
wal_keep_size = '512MB'

# Slot 最多允许保留多少 WAL;超限后可能使 Slot 失效
max_slot_wal_keep_size = '2GB'

# pg_rewind 前置条件之一;也可以依赖初始化时启用的数据校验和
wal_log_hints = on
full_page_writes = on

# [PG18] 生产环境可按 Slot 生命周期设置,例如:
# idle_replication_slot_timeout = '24h'

创建复制用户:

CREATE ROLE repl
WITH LOGIN REPLICATION
PASSWORD 'replace-with-a-managed-secret';

Primary 的 pg_hba.conf

host  replication  repl  10.0.0.11/32  scram-sha-256

生产环境优先使用专用复制账号、TLS、证书或受管 Secret,并限制源地址。复制流包含能够重建数据库的信息,不应开放给非可信用户。

使用 pg_basebackup 初始化 Standby:

pg_basebackup \
  --dbname="host=10.0.0.10 port=5432 user=repl application_name=standby_az_b" \
  --pgdata="$PGDATA_STANDBY" \
  --write-recovery-conf \
  --wal-method=stream \
  --create-slot \
  --slot=standby_az_b \
  --progress

--write-recovery-conf 会创建 standby.signal,并向目标的 postgresql.auto.conf 写入连接配置;如果指定了 Slot,也会记录对应设置。(PostgreSQL)

验证角色:

-- Primary 应返回 false
SELECT pg_is_in_recovery();

-- Standby 应返回 true
SELECT pg_is_in_recovery();

-- [PG14+] 更精确地区分普通只读状态和 Hot Standby 状态
SHOW in_hot_standby;

物理复制通常要求 Primary 与 Standby 使用相同大版本和兼容的物理架构;它不是跨大版本升级方案。Standby 的起点是 Base Backup,随后通过 WAL 追赶。(PostgreSQL)


5.2 primary_conninfo 和 Slot

Standby 的核心恢复设置:

primary_conninfo = '
    host=10.0.0.10
    port=5432
    user=repl
    application_name=standby_az_b
    sslmode=verify-full
'

primary_slot_name = 'standby_az_b'
recovery_target_timeline = 'latest'

不要把明文密码长期写在配置文件中。可使用权限受控的 .pgpass、证书或 Secret 管理系统。

手动创建 Physical Replication Slot:

SELECT *
FROM pg_create_physical_replication_slot(
    'standby_az_b',
    true
);

查看 Slot:

SELECT
    slot_name,
    slot_type,
    active,
    active_pid,
    restart_lsn,
    wal_status,
    safe_wal_size,
    inactive_since,
    invalidation_reason
FROM pg_replication_slots;

restart_lsn 是该 Slot 仍可能需要的最旧 WAL 位置。Slot 不活跃或消费过慢时,它会长期停留在较旧位置,从而阻止相关 WAL 回收。wal_status 可能为 reservedextendedunreservedlostsafe_wal_size 表示距离进入危险状态还可生成多少 WAL。(PostgreSQL)


5.3 异步与同步配置

默认 Streaming Replication 是异步的。Primary 的事务提交不等待 Standby,因此客户端已收到成功的事务可能尚未到达最终被提升的 Standby。(PostgreSQL)

优先级同步复制:

synchronous_standby_names = 'FIRST 1 (standby_az_b, standby_az_c)'

含义:

  • 优先选择 standby_az_b
  • 如果它不可用,则使用 standby_az_c
  • 提交只需一个当前同步 Standby 确认。

Quorum 同步复制:

synchronous_standby_names = 'ANY 2 (standby_az_b, standby_az_c, standby_az_d)'

含义:

  • 三个候选 Standby 中任意两个确认即可。
  • 不依赖固定优先顺序。
  • 只能容忍一个候选失联;若只剩一个,要求同步的提交会等待。

FIRST 是优先级选择,ANY 是 Quorum 选择。被计入同步确认的 Standby 必须直接连接 Primary;级联下游不能被 Primary 直接计入同步确认。(PostgreSQL)

synchronous_commit 语义

Primary 本地 WALStandby 阶段Standby 查询可见主要代价
remote_apply已持久化已 Replay是,新 Snapshot 可见RTT、Standby I/O、Replay 延迟
on已持久化已持久化不保证已 ReplayRTT、Standby WAL Flush
remote_write已持久化已写入 Standby OS不保证RTT;不能抵抗 Standby OS Crash
local已持久化不等待复制不保证异步复制 RPO
off不等待当前事务本地刷盘不等待复制不保证Primary Crash 可丢近期成功事务

按事务控制:

BEGIN;

SET LOCAL synchronous_commit = 'remote_apply';

INSERT INTO critical_ledger(account_id, delta_cents)
VALUES ($1, $2);

COMMIT;

remote_apply 等待同步 Standby Replay 提交记录,因此能在简单拓扑中提供因果一致的读负载均衡,但会比其他模式增加更多提交延迟。on 等待同步 Standby 持久化,remote_write 只等待写入其操作系统,local 不等待远端。(PostgreSQL)


5.4 复制监控 SQL

Primary:观察四阶段 LSN

SELECT
    application_name,
    client_addr,
    state,
    sync_state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    pg_wal_lsn_diff(sent_lsn, write_lsn)::bigint
        AS network_or_receiver_write_gap_bytes,
    pg_wal_lsn_diff(write_lsn, flush_lsn)::bigint
        AS standby_flush_gap_bytes,
    pg_wal_lsn_diff(flush_lsn, replay_lsn)::bigint
        AS standby_replay_gap_bytes,
    pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn)::bigint
        AS total_replay_gap_bytes,
    write_lag,
    flush_lag,
    replay_lag
FROM pg_stat_replication
ORDER BY application_name;

重要字段:

  • state:常见值包括 catchupstreaming
  • sync_stateasyncpotentialsyncquorum
  • sent_lsn:WAL Sender 已发送。
  • write_lsn:Standby 已写入操作系统。
  • flush_lsn:Standby 已刷盘。
  • replay_lsn:Standby 已应用。
  • write_lagflush_lagreplay_lag:历史提交路径延迟,不是“还需要多久追完”的 ETA;空闲时也不保证立即变成零。(PostgreSQL)

Standby:观察 Receiver 和 Replay

SELECT
    pg_is_in_recovery() AS is_standby,
    pg_last_wal_receive_lsn() AS receive_lsn,
    pg_last_wal_replay_lsn() AS replay_lsn,
    pg_wal_lsn_diff(
        pg_last_wal_receive_lsn(),
        pg_last_wal_replay_lsn()
    )::bigint AS receive_replay_gap_bytes,
    clock_timestamp() - pg_last_xact_replay_timestamp()
        AS last_replayed_transaction_age;

last_replayed_transaction_age 在 Primary 没有新事务时会持续增长,因此不能单独作为复制故障指标。

查看 WAL Receiver:

SELECT
    pid,
    status,
    receive_start_lsn,
    receive_start_tli,
    written_lsn,
    flushed_lsn,
    received_tli,
    latest_end_lsn,
    latest_end_time,
    slot_name,
    sender_host,
    sender_port
FROM pg_stat_wal_receiver;

written_lsn 只表示已写入但未必持久化;数据完整性判断应看 flushed_lsn。(PostgreSQL)

Slot WAL 保留量

SELECT
    slot_name,
    active,
    restart_lsn,
    wal_status,
    safe_wal_size,
    inactive_since,
    invalidation_reason,
    pg_wal_lsn_diff(
        pg_current_wal_lsn(),
        restart_lsn
    )::bigint AS retained_wal_bytes
FROM pg_replication_slots
ORDER BY retained_wal_bytes DESC NULLS LAST;

Recovery Conflict

SELECT
    datname,
    confl_tablespace,
    confl_lock,
    confl_snapshot,
    confl_bufferpin,
    confl_deadlock,
    confl_active_logicalslot
FROM pg_stat_database_conflicts
ORDER BY datname;

同步提交等待

PostgreSQL 18 可检查:

SELECT
    pid,
    usename,
    application_name,
    xact_start,
    state,
    wait_event_type,
    wait_event,
    query
FROM pg_stat_activity
WHERE wait_event = 'WaitForStandbyConfirmation'
ORDER BY xact_start;

WaitForStandbyConfirmation 表示 Backend 正在等待物理 Standby 接收和刷盘确认。(PostgreSQL)


5.5 版本提示

版本本章相关说明
PostgreSQL 14可使用 in_hot_standby 区分 Hot Standby 状态
PostgreSQL 15可结合恢复预取相关视图观察 Replay I/O
PostgreSQL 16pg_stat_io 提供更系统的 I/O 观测
PostgreSQL 17本章核心复制语义与 18 基本一致,但没有 PG18 Slot 闲置超时
PostgreSQL 18新增 idle_replication_slot_timeout;AIO 和 I/O 监控进一步增强

[PG18] idle_replication_slot_timeout 默认值为 0,即不自动失效闲置 Slot。失效检查发生在 Checkpoint,因此超过超时后不一定立即触发。(PostgreSQL)


6. 底层原理

6.1 一次提交的复制时间线

设事务生成的提交记录位置为 L_commit

T0  Backend 修改数据页并生成 WAL
T1  写入 Commit WAL Record
T2  Primary WAL 写入并 Flush 到 L_commit
T3  WAL Sender 发送到 Standby
T4  WAL Receiver 接收并写入 Standby 文件系统
T5  Standby Flush 到持久存储
T6  Startup Process Replay 到 L_commit
T7  Standby 新查询取得新 Snapshot,看到事务

不同模式的返回点:

local / 无同步 Standby的 on  → T2
remote_write                 → T4
on                           → T5
remote_apply                 → T6

remote_apply 并不是“让查询更快”,而是把 Replay 延迟加入提交路径。若 Standby 正在处理大量 WAL、发生 I/O 抖动、CPU 饱和或 Replay 被查询冲突拖延,Primary 的提交 P95/P99 都会受到影响。

6.2 receive、write、flush、replay 的区别

Primary current WAL
    ↓ sender gap
sent_lsn
    ↓ network / receiver scheduling
write_lsn
    ↓ standby WAL fsync
flush_lsn
    ↓ startup replay CPU/I/O/conflict
replay_lsn

发送差距

pg_current_wal_lsn() - sent_lsn

可能表示:

  • WAL Sender 调度不足;
  • Primary CPU 饱和;
  • 网络发送阻塞;
  • 下游读取太慢导致 Socket Backpressure。

网络或 Receiver 写入差距

sent_lsn - write_lsn

可能表示:

  • 网络丢包或带宽不足;
  • Standby WAL Receiver 调度不及时;
  • Standby 文件系统写入变慢。

Flush 差距

write_lsn - flush_lsn

可能表示:

  • Standby WAL 设备 Flush 延迟高;
  • 存储队列过深;
  • 虚拟化存储抖动。

Replay 差距

flush_lsn - replay_lsn

可能表示:

  • Startup Process CPU 不足;
  • 数据文件随机 I/O 较慢;
  • Primary 生成 WAL 的速率高于 Standby Replay 能力;
  • Recovery Conflict 正在等待查询;
  • Standby 分析查询和 Replay 争用 CPU、内存或磁盘。

6.3 异步复制可能丢失什么

异步复制可能丢失的是:

客户端已经从旧 Primary 收到提交成功,但尚未到达并持久化到最终被提升 Standby 的事务。

不是所有“Replay 之前的事务”都会丢失。已经持久化在 Standby pg_wal 中但尚未 Replay 的 WAL,Promotion 前仍可以被恢复和应用。真正的安全边界取决于候选节点已经接收并持久化了多少 WAL,以及 Failover 选择了哪一个节点。

异步 RPO 不能只用“相差几秒”描述,应同时记录:

  • Primary 当前 Flush LSN;
  • 候选 Standby Flush LSN;
  • 两者字节差;
  • 提交速率和业务事务分布;
  • 选择候选节点时的实际状态。

6.4 同步复制是否在所有故障中都等于零数据丢失

不是。

同步复制只能在明确的配置和故障模型中提供更强保证。例如 synchronous_commit=on 且提交确实获得了指定同步 Standby 的持久化确认时,单独丢失 Primary 通常不会丢失该事务。

以下情况仍可能造成数据丢失或业务损坏:

  1. Primary 与所有确认节点同时丢失或存储同时损坏。
  2. Failover 错误地选择了一个更落后的异步 Standby。
  3. 某些事务使用了 localoff
  4. 运维期间降低了 synchronous_standby_names 的要求。
  5. 存储设备或控制器错误地报告持久化完成。
  6. 逻辑误删、恶意操作和应用 Bug 被同步复制到所有节点。
  7. Primary 在事务等待同步确认期间崩溃:恢复后 Primary 可能认为事务已提交,而 Standby 上不一定存在;客户端也可能没有收到明确结果。
  8. 两个节点同时可写,产生 Split Brain。

PostgreSQL 官方也明确指出,Primary 在等待同步确认时重启后,相关事务可在 Primary 恢复时被标记为已提交,但不能确定所有 Standby 都已收到对应 WAL。(PostgreSQL)

因此:

同步复制不是备份,不替代 PITR,也不替代 Fencing、候选选择和恢复验证。

6.5 同步 Standby 失联时写请求会怎样

设配置:

synchronous_standby_names = 'ANY 2 (s1, s2, s3)'

当只有一个候选可用时:

  1. DML 本身仍可能执行。
  2. Commit WAL 可以先在 Primary 持久化。
  3. Backend 在提交返回阶段等待足够的 Standby 确认。
  4. 事务持有的行锁、表锁及其他资源可能继续占用。
  5. 后续事务形成锁队列。
  6. Writer Pool 连接逐步耗尽。
  7. 应用 goroutine 在 Pool 外继续排队。
  8. 超时、取消和重试可能形成重试风暴。

恢复方式包括:

  • 恢复足够数量的同步 Standby;
  • 由经过授权的 HA 控制面降低同步要求;
  • 在接受更大 RPO 的前提下临时切换关键事务的同步级别;
  • 对入口执行 Admission Control,而不是无限积压。

不要把“同步节点掉线后自动退化为异步”视为 PostgreSQL 默认行为。未经重配置,要求同步确认的提交可能一直等待。(PostgreSQL)

6.6 FIRST 与 ANY

FIRST

synchronous_standby_names = 'FIRST 2 (s1, s2, s3)'
  • s1s2 优先成为同步 Standby。
  • s3 是候补。
  • 适合明确的机房优先级和确定性故障域选择。
  • 风险是高优先级节点性能较差时,提交延迟仍被它决定。

ANY

synchronous_standby_names = 'ANY 2 (s1, s2, s3)'
  • 任意两个完成即可。
  • 适合多个相近节点的 Quorum。
  • 较慢节点不一定处于每次提交的关键路径。
  • 仍需保证候选集合具有合理故障域;同一机架的三个节点不等于三个独立故障域。

6.7 Slot、wal_keep_size 和磁盘

wal_keep_size

  • 只规定为 Standby 额外保留的最低 WAL 量;
  • 不是精确保留时间;
  • 不是磁盘使用上限;
  • Standby 落后超过该范围后,所需 WAL 可能被回收;
  • 若 Archive 仍有对应 WAL,Standby 可以从 Archive 追赶。

Replication Slot:

  • 根据消费者进度精确保留所需 WAL;
  • restart_lsn 表示最旧保护位置;
  • 消费者失联不会自动释放保护;
  • 因此可能无限扩大 pg_wal

max_slot_wal_keep_size

  • 默认 -1,即 Slot 可以无限保留 WAL;
  • 设置上限后,超出上限可能使所需 WAL 被删除;
  • 它保护 Primary 磁盘,但可能牺牲 Standby 的可继续复制能力;
  • 不是“让 Standby 自动追上”的限流器。(PostgreSQL)

[PG18] idle_replication_slot_timeout

  • 只针对长时间不活跃的 Slot;
  • 检查在 Checkpoint 时发生;
  • 不能代替 Lag、safe_wal_size 和磁盘告警;
  • 合法的长期离线灾备 Slot 可能被误判,因此必须结合恢复策略设置。(PostgreSQL)

6.8 为什么 Standby 查询会被取消

Primary 已经完成的操作会以 WAL 形式到达 Standby。例如:

  • DROP TABLE 取得了 Access Exclusive Lock;
  • VACUUM 删除了旧 Tuple;
  • 删除了 Database 或 Tablespace;
  • WAL Replay 需要修改某个正被查询固定的 Buffer。

Standby 不能要求 Primary 回滚已经提交的操作,只能:

  1. 暂停 Replay,等待查询结束;或
  2. 取消冲突查询,让 Replay 继续。

若等待超过 max_standby_streaming_delay,冲突查询会被取消。这个参数是接收到的 WAL 最多允许被延迟应用多久,不是单条查询固定可运行多久。此前其他查询已经消耗过延迟预算时,后续查询可能更快被取消。(PostgreSQL)

6.9 hot_standby_feedback 为何会导致 Primary 膨胀

启用:

hot_standby_feedback = on

Standby 会把活跃查询所需的 MVCC Horizon 反馈给上游。Primary 的 VACUUM 因而不能删除这些查询仍可能看到的旧 Tuple。

收益:

  • 降低因 VACUUM Cleanup Record 造成的 Standby 查询取消。

代价:

  • Primary Dead Tuple 持续存在;
  • 表和索引膨胀;
  • Cache 命中率下降;
  • 查询扫描更多页面;
  • VACUUM 工作量增加;
  • WAL、I/O 和存储使用进一步上升。

它也不能解决所有冲突,例如 Primary DDL、Database Drop 和 Tablespace Drop。级联拓扑中,反馈会继续向上游传播。(PostgreSQL)

6.10 复制延迟为零为何不必然等于业务一致

即使观测到:

sent_lsn = write_lsn = flush_lsn = replay_lsn

仍可能出现业务上的陈旧读:

  1. 监控采样与业务读取不是同一时刻。
  2. 业务读取被路由到另一台更落后的 Replica。
  3. 读事务在 Replay 之前已经取得 Repeatable Read Snapshot。
  4. 连接池仍复用故障切换前的旧连接。
  5. 缓存、搜索索引、CDC 消费者或异步任务尚未更新。
  6. LSN 状态反馈存在上报间隔。
  7. 监控事务缓存了统计视图 Snapshot。
  8. 业务一致性包含多个数据库、消息系统或外部服务。
  9. Failover 后 Timeline 已变化,仅比较数值 LSN 可能错误。

可靠的单集群 Read-After-Write 至少应满足:

写事务成功
→ 获得写后 LSN Barrier
→ 在目标 Replica 上确认 replay_lsn >= Barrier
→ 随后取得一个新 Snapshot
→ 在同一 Replica 上执行读取

6.11 Timeline、Promotion 和 pg_rewind

Promotion 后会创建新的 Timeline。Timeline ID 进入 WAL 文件名,并生成 Timeline History File,记录从哪条历史、哪个位置分叉。(PostgreSQL)

假设:

Timeline 1:
A ─ B ─ C ─ D       旧 Primary 继续误写
          \
Timeline 2:
           E ─ F    新 Primary Promotion 后写入

旧 Primary 不能直接作为可写节点重新加入,因为:

  • 两边都可能已经接受新事务;
  • 相同 LSN 数值可能属于不同 Timeline;
  • 数据页、事务状态、序列和业务约束已发生分叉;
  • 直接接入会形成 Split Brain;
  • PostgreSQL 不会自动合并两条物理历史。

正确方式:

  • 保持旧 Primary 被 Fence;
  • 使用 pg_rewind 将其回退到共同分叉点并同步差异;
  • 或销毁旧数据目录,重新执行 Base Backup。

pg_rewind 要求目标节点停止;目标集群需要启用数据校验和或 wal_log_hints=on,同时保持 full_page_writes=on。执行中失败可能使目标数据目录不可恢复,此时应重新 Base Backup。(PostgreSQL)


7. 内部数据结构和状态

对象或状态作用关键变化
WAL Record描述数据页、事务和系统状态变化Primary 生成,Standby Replay
WAL SegmentWAL Record 的磁盘容器受 Checkpoint、Archive、Slot 和 wal_keep_size 影响
Commit Record表示事务提交同步复制等待围绕该位置展开
standby.signal指示实例进入 Standby ModePromotion 后退出恢复
Control File保存 Checkpoint、Timeline 等控制信息Promotion、恢复和 Checkpoint 时更新
Timeline History描述 Timeline 分叉关系Promotion/PITR 完成后创建
WAL Sender State保存已发送及 Standby 回报位置展示于 pg_stat_replication
WAL Receiver State保存接收、写入、刷盘位置展示于 pg_stat_wal_receiver
Startup Process执行 Redo 和恢复冲突处理推进 Replay LSN
Replication Slot持久记录 WAL/Xmin 保留要求进度落后时阻止资源回收
restart_lsnSlot 仍需要的最旧 WAL消费推进后才能前移
shared_buffers同时承载查询访问和 WAL Replay 修改分析查询可能与 Replay 争用
OS Page Cache缓存 WAL 和数据文件页面不能把“写入 Page Cache”当作持久化
Standby Snapshot确定只读事务可见数据长 Snapshot 可与 Cleanup WAL 冲突
Buffer Pin查询固定页面时的局部资源可能导致 Replay Conflict
Lock StatePrimary DDL 对象锁通过 WAL 影响 Standby冲突时 Standby 查询可能被取消
RestartpointStandby 恢复过程中的 Checkpoint 类机制影响恢复时间和 I/O 波动
统计状态LSN、Lag、Conflict、I/O、Pool 数据有采样延迟,不能视为原子真相

8. 场景和选型决策

业务场景推荐方案不推荐方案原因性能代价并发代价一致性代价高可用代价运维复杂度
同城关键账务,单节点故障 RPO≈0跨故障域同步 Standby,on仅异步 Replica等待远端持久化增加 RTT 和 Flush P99Commit 持锁更久强于异步同步节点不足会阻塞写入
写后立即从 Replica 读取LSN Barrier,超时回 Primary;或关键事务 remote_apply固定 Sleep 后读 ReplicaSleep 无法证明 ReplayBarrier 增加读延迟;remote_apply 增加写延迟Pool 中会产生等待可提供因果一致性Failover 后需失效旧 Token中高
跨区域灾备异步复制加 WAL Archive跨洲所有事务 remote_applyWAN RTT 和抖动过大复制带宽与存储较小接受可量化 RPO低提交延迟,高灾备 RPO
BI 长查询独立异步分析 Replica与同步 HA Replica 混用长查询会冲突或拖慢 Replay需要额外硬件分析查询需准入控制数据可能陈旧分析节点不作为首选 Failover
普通列表、报表读扩展异步读副本对所有查询要求 Primary 强一致可接受短暂陈旧增加副本读取能力Reader Pool 独立排队最终一致Replica 不能扩展写入
三个相近 AZANY 2 Quorum把三台同机架节点当三故障域任意两台确认两个最快候选决定路径少一个节点仍可提交强持久性需保证两个确认可用
明确主备优先级FIRST 1/2候选顺序随意行为可预测高优先级慢节点影响延迟候补切换期间可能抖动与确认级别相关简单候补模型
多地域下游读节点Cascading Replication所有节点直连 Primary减少 Primary 连接和跨区流量级联 Lag 累加上游故障影响下游下游更陈旧级联复制本身是异步
Standby 短时中断后必须追赶Slot + WAL Archive + 上限监控只设巨大 wal_keep_sizeSlot 更精确,Archive 提供兜底需要磁盘和 Archive 带宽无直接影响不影响查询语义降低重建概率中高
可随时重建的临时 Replica有限 wal_keep_size 或受控 Slot永久无上限 Slot避免废弃节点填满磁盘可能需要重建无直接影响重建期间无读能力RTO 增加

内置 Cascading Replication 当前为异步;同步设置不能使下游级联节点直接成为 Primary 的同步确认者。(PostgreSQL)


9. 高性能分析

所有参数决策都必须先收集:

  • 数据规模和增长速度;
  • 平均与 P99 行宽;
  • WAL 每秒生成量及峰值;
  • 读写比例;
  • 事务大小和并发数;
  • Primary 与 Standby CPU、内存和存储;
  • 网络 RTT、抖动、吞吐和丢包;
  • 正常与故障期间的 RPO/RTO;
  • 提交、读请求和 Failover 的 SLO。

9.1 资源维度

资源复制影响主要指标
CPUPrimary 生成 WAL、Sender 发送;Standby Receiver、Replay 与查询竞争 CPUCPU 使用率、Run Queue、Replay Bytes/s
内存每台 Replica 都有独立 shared_buffers、连接和工作内存Cache 命中率、Pool 连接、临时文件
shared_buffersReplay 写入的页面和分析查询读取页面相互驱逐Buffer Read、Hit、Replay Lag
OS Page CacheWAL 和数据页可能先进入内核缓存;不代表已经持久化Flush 延迟、设备队列、脏页
顺序 I/OWAL 写入和传输通常接近顺序路径WAL Write/Flush 吞吐
随机 I/OReplay 更新数据页、索引页;读查询访问数据文件DataFileRead/Write、IOPS
网络 RTT同步复制提交至少包含网络往返RTT、重传、P95/P99
网络带宽必须长期高于 WAL 生成速率,并留峰值余量WAL Bytes/s、出口利用率
索引维护Primary 每次索引更新也会生成 WAL,所有 Replica 都必须 ReplayWAL/事务、索引数
Checkpoint首次修改页可能产生 Full Page Image,增加 WAL;Standby 有 RestartpointCheckpoint 时长、FPW、WAL 峰值
VACUUM生成 Cleanup WAL,可能触发 Recovery ConflictDead Tuple、Conflict、Replay Delay
Temporary FileReplica 报表 Spill 会占用磁盘带宽和空间Temp Bytes、磁盘队列
空间每个物理 Replica 保存完整集群,加上 WAL 和临时文件Data、Index、WAL、Temp 总量

9.2 PostgreSQL 18 AIO

[PG18] AIO 能让 Backend 对顺序扫描、Bitmap Heap Scan、VACUUM 等路径排队多个读请求,也提供相关等待事件和 pg_aios 视图。它可能改善 Standby 读查询或维护操作的 I/O 效率,但不会消除:

  • WAL Flush 的持久化要求;
  • 同步复制网络 RTT;
  • WAL Replay 的因果顺序;
  • Recovery Conflict;
  • 存储设备本身的尾延迟。

因此不能因为启用 AIO,就预期 remote_apply 的 Commit P99 自动显著下降。(PostgreSQL)

9.3 读写放大

写放大

一次业务写入可能产生:

Heap 修改
+ 多个 Index 修改
+ WAL
+ Full Page Image
+ Archive 写入
+ N 个 Replica WAL 写入
+ N 个 Replica 数据页 Replay

增加 Replica 并不会扩展 Primary 的写入能力;它会增加整个系统的写入和存储成本。

读放大

同一热点数据可能分别存在于:

  • Primary shared_buffers
  • 每台 Replica 的 shared_buffers
  • 每台服务器 OS Page Cache;
  • 应用缓存。

读副本扩展吞吐的同时,也增加内存和缓存预热成本。

9.4 尾延迟

同步复制的 Commit 延迟近似由以下较慢路径决定:

Primary WAL Flush
+ 网络 RTT
+ Standby Write/Flush
+ 可选 Replay
+ 调度抖动

remote_apply 还会包含:

  • Startup Process 排队;
  • 数据文件 I/O;
  • Standby CPU 竞争;
  • Recovery Conflict 等待;
  • 可能的 Checkpoint/Restartpoint 抖动。

性能评估必须同时记录吞吐量和 P50/P95/P99,不能只记录平均 Commit Latency。


10. 高并发分析

10.1 同步等待会延长锁持有时间

事务在等待同步确认时,数据库更改通常已经在 Primary 本地提交路径中推进,但客户端尚未收到结果。事务相关锁可能持续占用,造成:

同步 Standby 变慢
→ Commit 等待变长
→ 热点行锁持有时间变长
→ 后续事务排队
→ Writer Pool 被占满
→ 应用 goroutine 在 Pool 外排队
→ 超时与重试增加
→ WAL 和系统负载进一步上升

同步等待本身不一定是普通的数据库 Blocker PID,因此 pg_blocking_pids() 可能找不到“阻塞 Commit 的 Standby”。应结合 wait_eventpg_stat_replication 分析。

10.2 MVCC 与长事务

Standby 查询也使用 MVCC Snapshot。

  • READ COMMITTED 每条语句取得新 Snapshot。
  • REPEATABLE READ 事务长期保留同一 Snapshot。
  • 长 Snapshot 更容易与 Primary VACUUM Cleanup Record 冲突。
  • 启用 hot_standby_feedback 后,长 Snapshot 又可能把膨胀压力传回 Primary。

10.3 热点行和热点索引页

热点更新会同时造成:

  • Primary 行锁竞争;
  • Heap 和 Index Page 高频修改;
  • WAL 生成速率上升;
  • Replica Replay 压力上升;
  • 同步复制确认次数增加。

读副本不能消除写热点。它只可能把不要求最新状态的读取从 Primary 移走。

10.4 连接、活跃查询、TPS 和排队请求

必须区分:

指标含义
应用 goroutine 数有多少并发业务任务
Pool 最大连接数应用最多可占用多少数据库连接
数据库连接数当前节点建立的连接总数
Active Query 数真正在执行或等待数据库资源的查询
TPS每秒完成的事务数
Pool Acquire 等待数等待可用连接的请求
同步提交等待数已进入数据库但等待 Standby 确认的请求
Replica Replay LagWAL 应用落后程度

增加 goroutine 不会增加数据库容量,只会把排队从数据库移到应用内存或反向移动。

10.5 Backpressure 和 Admission Control

当出现同步复制故障或 Replica Lag 时:

  • 对非关键读请求回退 Primary 前应设置并发上限,避免压垮 Primary。
  • 对分析查询设置并发配额和超时。
  • Writer Pool 耗尽前应在 API 层拒绝或延后低优先级写入。
  • 重试必须有最大次数、指数退避和随机抖动。
  • Commit 结果不确定的写入必须依赖 Idempotency Key 或业务查询确认,不能盲目重试。

11. 高可用分析

11.1 RPO

复制模式典型 RPO 含义
异步可能丢失尚未持久化到被提升节点的已确认事务
remote_write可抵抗 Standby PostgreSQL 进程故障,但 Standby OS Crash 可能丢数据
on被确认事务已在同步 Standby 持久化
remote_applyon 类似的远端持久性,并已可被新查询看到
多节点 Quorum取决于确认数量、故障域和 Failover 候选选择

11.2 RTO

RTO 包括:

故障检测
+ 确认 Primary 不可继续服务
+ Fencing
+ 选择候选节点
+ Promotion
+ DNS/VIP/Proxy/Service 更新
+ 应用连接池失效与重连
+ 健康检查
+ 业务验证

仅测量 pg_promote() 的执行时间会严重低估真实 RTO。

11.3 Planned Switchover

建议顺序:

  1. 确认目标 Standby 健康并可承担容量。
  2. 暂停或排空写流量。
  3. 在旧 Primary 记录最终 Flush LSN。
  4. 等待目标 Standby Replay 至该位置。
  5. 停止或 Fence 旧 Primary。
  6. Promotion。
  7. 验证 Timeline 和读写。
  8. 更新路由。
  9. 主动关闭旧连接池并重新建立连接。
  10. 使用 pg_rewind 或重建将旧 Primary 加回为 Standby。
  11. 验证数据和业务不变量。
  12. 恢复写流量。

11.4 Unplanned Failover

必须回答:

  • 原 Primary 是否真的不可访问,还是只对控制节点不可见?
  • 能否通过存储隔离、节点断电、网络 ACL 或 STONITH 完成 Fencing?
  • 哪个 Standby 的 Flush LSN 最先进?
  • 哪个 Standby 位于独立故障域?
  • 提升它会产生多少 RPO?
  • 旧客户端连接是否仍可能写旧 Primary?
  • Commit 返回错误的请求如何对账?

没有 Fencing 的 Promotion 不应被描述为安全高可用。

11.5 Failback

Failback 不是“把旧 Primary 开回来”。

正确流程是:

旧 Primary 保持停止
→ 确认 Timeline 分叉
→ pg_rewind 或新 Base Backup
→ 配置为当前 Primary 的 Standby
→ 追赶并验证
→ 未来再执行一次独立的 Planned Switchover

11.6 复制不是备份

物理复制会忠实复制:

  • 误删;
  • 错误 UPDATE;
  • DROP TABLE;
  • 逻辑数据损坏;
  • 恶意操作;
  • 某些页面级损坏。

因此仍需:

  • 独立 WAL Archive;
  • 可恢复的 Base Backup;
  • PITR;
  • 不可变或隔离存储;
  • 定期恢复演练;
  • 业务校验。

12. 三维影响矩阵

维度相关度核心收益主要风险关键指标
高性能读扩展、备份卸载、故障后快速恢复同步 RTT、Replay 资源争用、读写与空间放大Commit P95/P99、WAL/s、Replay Bytes Lag、I/O
高并发分离部分只读请求同步 Commit 持锁、长查询冲突、Pool 排队与重试风暴Active Query、Pool Acquire Wait、Conflict、Sync Wait
高可用提供可提升的物理副本异步数据丢失、脑裂、错误候选、Slot 填盘、旧连接RPO、RTO、Timeline、Slot WAL、Failover 验证

13. 实验

以下实验只能在可丢弃环境执行。不要在生产环境暂停 Replay、缩短冲突等待、Promote 节点或运行未经演练的 pg_rewind


实验一:建立 Primary 和 Standby,观察各阶段 LSN

13.1 实验目标

  • 建立一主一备物理流复制。
  • 观察 sent_lsnwrite_lsnflush_lsnreplay_lsn
  • 理解提交与 Standby 可见性之间的时间线。

13.2 版本和扩展

  • PostgreSQL 18。
  • 不需要扩展。
  • 两个可丢弃实例。
  • Primary:10.0.0.10
  • Standby:10.0.0.11

13.3 准备

按照 5.1 节初始化 Standby,然后在 Primary 执行:

CREATE TABLE replication_probe (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

确认连接:

-- Primary
SELECT application_name, state, sync_state
FROM pg_stat_replication;

-- Standby
SELECT pg_is_in_recovery();
SELECT status, slot_name, sender_host
FROM pg_stat_wal_receiver;

13.4 Session A:Primary 写入

BEGIN;

INSERT INTO replication_probe(payload)
VALUES ('chapter-21-lsn-probe')
RETURNING id;

SELECT
    pg_current_wal_insert_lsn(),
    pg_current_wal_write_lsn(),
    pg_current_wal_flush_lsn();

COMMIT;

13.5 Session B:Primary 监控

提交前后反复执行:

SELECT
    application_name,
    state,
    sync_state,
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    pg_wal_lsn_diff(
        pg_current_wal_lsn(),
        replay_lsn
    )::bigint AS total_gap_bytes
FROM pg_stat_replication;

13.6 Session C:Standby 监控和查询

SELECT
    written_lsn,
    flushed_lsn,
    received_tli,
    latest_end_lsn
FROM pg_stat_wal_receiver;

SELECT
    pg_last_wal_receive_lsn(),
    pg_last_wal_replay_lsn();

SELECT *
FROM replication_probe
ORDER BY id DESC
LIMIT 5;

13.7 明确时间线

时间操作等待/失败/提交
T0Session A BEGIN不等待复制
T1INSERT未提交,其他会话不可见
T2COMMIT异步模式只等待 Primary 本地提交路径
T3WAL Sender 发送Session B 的 sent_lsn 前移
T4WAL Receiver 写入和 Flushwrite_lsnflush_lsn 前移
T5Startup Process Replayreplay_lsn 前移
T6Standby 新查询记录可见

本实验没有预期失败。唯一明确提交点是 Session A 的 COMMIT

13.8 预期结果

  • LSN 值总体满足:
sent_lsn >= write_lsn >= flush_lsn >= replay_lsn

采样和状态上报并非原子操作,极短时间观测不必机械依赖每一列严格同步。

  • Standby 必须在 Replay 后才能通过新 Snapshot 看到记录。
  • flush_lsn 追上不等于查询已经可见。

13.9 统计指标

记录:

  • pg_stat_replication 四阶段 LSN;
  • pg_stat_wal_receiver 的写入和 Flush 位置;
  • Primary 和 Standby WAL 设备延迟;
  • 测试时的 PostgreSQL 配置、并发数和网络 RTT。

本实验不是查询计划实验,EXPLAIN 不是主要诊断手段。

13.10 清理

DROP TABLE replication_probe;

13.11 生产安全警告

不要仅凭一次 LSN 相等就宣告复制健康。必须持续监控字节 Lag、时间 Lag、Receiver 状态、Slot、磁盘和业务探针。


实验二:暂停 Replay 并观察 Lag

13.12 实验目标

区分“WAL 已收到”和“WAL 已应用”。

13.13 版本和扩展

  • PostgreSQL 18。
  • 不需要扩展。
  • 使用实验一的拓扑。

13.14 准备数据

Primary:

CREATE TABLE replay_pause_demo (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    payload text NOT NULL
);

13.15 Session A:Standby 暂停 Replay

SELECT pg_wal_replay_pause();

SELECT pg_get_wal_replay_pause_state();

只有返回 paused 才表示真正暂停。

13.16 Session B:Primary 生成 WAL

INSERT INTO replay_pause_demo(payload)
SELECT repeat(md5(g::text), 8)
FROM generate_series(1, 100000) AS g;

COMMIT;

每条独立 INSERT 语句默认已经在语句结束时提交;上面的显式 COMMIT 若不在事务中只会给出提示。需要单事务时应显式:

BEGIN;

INSERT INTO replay_pause_demo(payload)
SELECT repeat(md5(g::text), 8)
FROM generate_series(1, 100000) AS g;

COMMIT;

13.17 Session C:观察 Lag

Primary:

SELECT
    sent_lsn,
    write_lsn,
    flush_lsn,
    replay_lsn,
    pg_wal_lsn_diff(flush_lsn, replay_lsn)::bigint
        AS flush_replay_gap_bytes
FROM pg_stat_replication;

Standby:

SELECT
    pg_get_wal_replay_pause_state(),
    pg_last_wal_receive_lsn(),
    pg_last_wal_replay_lsn(),
    pg_wal_lsn_diff(
        pg_last_wal_receive_lsn(),
        pg_last_wal_replay_lsn()
    )::bigint AS receive_replay_gap_bytes;

SELECT written_lsn, flushed_lsn
FROM pg_stat_wal_receiver;

13.18 时间线

时间操作等待/失败/提交
T0Standby 请求暂停可能短暂处于 pause requested
T1状态变为 pausedReplay 停止
T2Primary 大量写入并提交WAL 正常发送
T3Standby Receiver 继续接收/FlushReceive LSN 前移
T4Replay LSN 停止Replay Gap 增长
T5恢复 ReplayStartup Process 追赶
T6Replay LSN 追上数据重新可见

13.19 恢复

SELECT pg_wal_replay_resume();

SELECT pg_get_wal_replay_pause_state();

13.20 预期结果

暂停期间:

receive_lsn / flushed_lsn 持续前移
replay_lsn 基本不动

恢复后,Replay Gap 应逐步缩小。

WAL Replay 暂停时,Streaming Receiver 仍可继续接收 WAL,因此磁盘最终可能被写满。(PostgreSQL)

13.21 清理

DROP TABLE replay_pause_demo;

13.22 生产安全警告

  • 操作前必须计算磁盘余量:
可暂停时间 ≈ 可用 pg_wal 空间 / 峰值 WAL 生成速率
  • 必须设置独立的自动恢复机制。
  • 不要让人工维护会话成为唯一的 Resume 手段。

实验三:Standby 长查询与 Primary VACUUM 的 Recovery Conflict

13.23 实验目标

复现 Primary VACUUM Cleanup WAL 导致 Standby 查询被取消。

13.24 版本和扩展

  • PostgreSQL 18。
  • 不需要扩展。
  • 可丢弃 Standby。

13.25 Standby 参数

ALTER SYSTEM SET hot_standby_feedback = 'off';
ALTER SYSTEM SET max_standby_streaming_delay = '5s';

SELECT pg_reload_conf();

SHOW hot_standby_feedback;
SHOW max_standby_streaming_delay;

13.26 Primary 准备数据

CREATE TABLE conflict_demo (
    id bigint PRIMARY KEY,
    payload text NOT NULL
) WITH (
    autovacuum_enabled = false
);

INSERT INTO conflict_demo
SELECT
    g,
    repeat(md5(g::text), 10)
FROM generate_series(1, 300000) AS g;

ANALYZE conflict_demo;

等待 Standby 追上。

13.27 Session B:Standby 长 Snapshot

BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;

-- 建立旧 Snapshot
SELECT count(*)
FROM conflict_demo;

-- 保持查询活跃
SELECT pg_sleep(120);

13.28 Session A:Primary 删除并 VACUUM

在 Standby 开始 pg_sleep 后执行:

DELETE FROM conflict_demo
WHERE id <= 200000;

VACUUM (VERBOSE, ANALYZE) conflict_demo;

DELETE 提交后产生 Dead Tuple,VACUUM 清理这些 Tuple 并生成相关 WAL。

13.29 Session C:Standby 诊断

SELECT
    datname,
    confl_snapshot,
    confl_lock,
    confl_bufferpin,
    confl_deadlock
FROM pg_stat_database_conflicts
WHERE datname = current_database();

查看日志,预期出现类似语义:

canceling statement due to conflict with recovery

不要依赖错误文本编写程序逻辑;程序应根据 SQLSTATE 和请求语义处理。

13.30 时间线

时间操作等待/失败/提交
T0Standby 开启 Repeatable Read事务未提交
T1count(*) 建立 SnapshotSnapshot 可见旧行
T2Standby pg_sleep查询活跃
T3Primary DELETE提交成功
T4Primary VACUUM生成 Cleanup WAL
T5Standby Startup Process 遇到冲突最多等待约配置的 Replay 延迟预算
T6延迟预算耗尽Standby 查询被取消
T7Replay 继续Conflict 计数增加

预期失败点是 Standby 的长查询。Primary 的 DELETEVACUUM 应正常完成。

13.31 可选对比:启用反馈

ALTER SYSTEM SET hot_standby_feedback = 'on';
SELECT pg_reload_conf();

重新实验时,VACUUM Cleanup 类查询取消可能减少,但应同时观察 Primary:

SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    last_vacuum,
    last_autovacuum
FROM pg_stat_user_tables
WHERE relname = 'conflict_demo';

SELECT
    pg_size_pretty(pg_total_relation_size('conflict_demo'))
        AS total_size;

反馈存在上报间隔,不保证立即生效,也不能消除 DDL 等其他冲突。

13.32 清理

Standby:

ROLLBACK;

ALTER SYSTEM RESET hot_standby_feedback;
ALTER SYSTEM RESET max_standby_streaming_delay;

SELECT pg_reload_conf();

Primary:

DROP TABLE conflict_demo;

13.33 生产安全警告

不要为了让所有分析查询永不取消而直接设置:

max_standby_streaming_delay = -1

在 HA Standby 上无限等待会把 Replay Lag 和 Failover RPO/RTO 推高。长分析查询应使用独立分析 Replica,并设置查询准入和资源限制。


实验四:Promotion 和 pg_rewind

13.34 实验目标

  • 安全提升 Standby。
  • 观察 Timeline 分叉。
  • 使用 pg_rewind 将旧 Primary 变为当前 Primary 的 Standby。

13.35 版本和前置条件

  • PostgreSQL 18。
  • 不需要扩展。
  • 目标旧 Primary 必须停止。
  • 集群初始化时启用数据校验和,或事先设置 wal_log_hints=on
  • full_page_writes=on
  • 必须是可丢弃环境。

13.36 准备

旧 Primary:

CREATE TABLE failover_probe (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    node_name text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

INSERT INTO failover_probe(node_name)
VALUES ('old-primary-before-switchover');

SELECT pg_current_wal_flush_lsn();

记录返回的 LSN,例如 0/50001A0

Standby:

SELECT
    pg_last_wal_replay_lsn(),
    pg_last_wal_replay_lsn() >= '0/50001A0'::pg_lsn
        AS caught_up;

确认 caught_up=true

13.37 Session A:停止并 Fence 旧 Primary

pg_ctl -D "$OLD_PRIMARY_PGDATA" stop -m fast

真实生产还需要:

  • 从负载均衡移除;
  • 阻断客户端网络;
  • 撤销 VIP;
  • 隔离共享存储;
  • 或通过 HA 控制面完成 STONITH。

仅停止数据库进程不一定构成充分 Fencing。

13.38 Session B:提升 Standby

SELECT pg_promote(true, 60);

SELECT pg_is_in_recovery();

预期第二条返回 false

在新 Primary 写入:

INSERT INTO failover_probe(node_name)
VALUES ('new-primary-after-promotion');

SELECT pg_walfile_name(pg_current_wal_lsn());

WAL 文件名前八位十六进制数字表示 Timeline ID。

13.39 Session C:运行 pg_rewind

确保旧 Primary 仍停止:

pg_rewind \
  --target-pgdata="$OLD_PRIMARY_PGDATA" \
  --source-server="host=10.0.0.11 port=5432 dbname=postgres user=rewind_operator sslmode=verify-full" \
  --write-recovery-conf \
  --progress

实验环境可临时使用高权限用户;生产应根据官方要求创建最小权限的专用 Rewind 角色。

检查 postgresql.auto.confprimary_conninfo、认证设置和 Slot,再启动旧 Primary:

pg_ctl -D "$OLD_PRIMARY_PGDATA" start

13.40 验证

旧 Primary 现在应是 Standby:

SELECT pg_is_in_recovery();

SELECT *
FROM failover_probe
ORDER BY id;

应同时看到 Promotion 前后的两条记录。

新 Primary:

SELECT
    application_name,
    state,
    sent_lsn,
    flush_lsn,
    replay_lsn
FROM pg_stat_replication;

13.41 时间线

时间操作等待/失败/提交
T0记录旧 Primary Flush LSN
T1等待 Standby Replay 到该 LSN明确等待
T2停止并 Fence 旧 Primary旧 Primary 不再可写
T3pg_promote()创建新 Timeline
T4新 Primary 写入提交成功
T5旧 Primary 仍处于旧历史不能启动为可写节点
T6pg_rewind目标必须停止
T7旧节点以 Standby 启动Replay 新 Timeline
T8验证拓扑恢复

13.42 失败处理

pg_rewind 报缺少 WAL、前置条件不满足或中途失败:

  1. 不要尝试直接启动目标为 Primary。
  2. 保存日志和原数据目录。
  3. 重新执行完整 Base Backup。
  4. 配置为 Standby。
  5. 验证数据和 Replay 状态。

13.43 清理

该实验已经改变拓扑。最安全的清理方式是销毁整个实验集群并重新初始化,而不是连续双向 Promotion。


14. Go:Writer Pool、Reader Pool 和 LSN Read-After-Write

14.1 数据表

CREATE TABLE orders (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    request_id text NOT NULL UNIQUE,
    customer_id bigint NOT NULL,
    amount_cents bigint NOT NULL CHECK (amount_cents >= 0),
    created_at timestamptz NOT NULL DEFAULT clock_timestamp()
);

环境变量:

export DATABASE_URL='postgres://app:[email protected]:5432/app'
export READER_DATABASE_URL='postgres://app:[email protected]:5432/app'

export QUERY_TIMEOUT='2s'
export READ_BARRIER_TIMEOUT='300ms'
export READ_BARRIER_POLL_INTERVAL='10ms'
export MAX_CONCURRENCY='16'

生产环境应根据请求 SLO 和故障预算调整这些值。连接池上限可通过 pgxpool 连接串参数设置,不能只根据 CPU 核数套用固定值。

14.2 可编译示例

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"
	"os/signal"
	"strconv"
	"sync"
	"syscall"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgxpool"
)

type CommitOutcome string

const (
	CommitCommitted  CommitOutcome = "committed"
	CommitRolledBack CommitOutcome = "rolled_back"
	CommitUnknown    CommitOutcome = "unknown"
)

type Order struct {
	ID          int64
	RequestID   string
	CustomerID  int64
	AmountCents int64
	CreatedAt   time.Time
}

type WriteResult struct {
	Order         Order
	ReplayBarrier string
	Outcome       CommitOutcome
}

type ReadResult struct {
	Order          Order
	Source         string
	FallbackReason string
}

type Store struct {
	writer         *pgxpool.Pool
	reader         *pgxpool.Pool
	queryTimeout   time.Duration
	barrierTimeout time.Duration
	pollInterval   time.Duration
}

func main() {
	ctx, stop := signal.NotifyContext(
		context.Background(),
		os.Interrupt,
		syscall.SIGTERM,
	)
	defer stop()

	store, err := newStore(ctx)
	if err != nil {
		log.Fatal(err)
	}
	defer store.Close()

	jobs := []func(context.Context) error{
		func(ctx context.Context) error {
			writeCtx, cancel := context.WithTimeout(
				ctx,
				store.queryTimeout,
			)
			defer cancel()

			wr, err := store.CreateOrder(
				writeCtx,
				"demo-request-001",
				42,
				1299,
			)
			if err != nil {
				// CommitCommitted 表示写入已经成功。
				// 此时即使 Barrier 获取失败,也不能重试写事务。
				if wr.Outcome != CommitCommitted {
					return err
				}
				log.Printf(
					"write committed, barrier unavailable: %v",
					err,
				)
			}

			readCtx, cancelRead := context.WithTimeout(
				ctx,
				store.queryTimeout+store.barrierTimeout,
			)
			defer cancelRead()

			rr, err := store.GetOrderReadAfterWrite(
				readCtx,
				wr.Order.ID,
				wr.ReplayBarrier,
			)
			if err != nil {
				return err
			}

			log.Printf(
				"order=%d source=%s fallback=%q",
				rr.Order.ID,
				rr.Source,
				rr.FallbackReason,
			)
			return nil
		},
	}

	if err := runBounded(
		ctx,
		envInt("MAX_CONCURRENCY", 4),
		jobs,
	); err != nil {
		log.Printf("workload failed: %v", err)
	}
}

func newStore(ctx context.Context) (*Store, error) {
	writerURL := os.Getenv("DATABASE_URL")
	readerURL := os.Getenv("READER_DATABASE_URL")
	if writerURL == "" || readerURL == "" {
		return nil, errors.New(
			"DATABASE_URL and READER_DATABASE_URL are required",
		)
	}

	writer, err := openPool(
		ctx,
		writerURL,
		"orders-writer",
		false,
	)
	if err != nil {
		return nil, fmt.Errorf("open writer pool: %w", err)
	}

	reader, err := openPool(
		ctx,
		readerURL,
		"orders-reader",
		true,
	)
	if err != nil {
		writer.Close()
		return nil, fmt.Errorf("open reader pool: %w", err)
	}

	store := &Store{
		writer: writer,
		reader: reader,
		queryTimeout: envDuration(
			"QUERY_TIMEOUT",
			2*time.Second,
		),
		barrierTimeout: envDuration(
			"READ_BARRIER_TIMEOUT",
			300*time.Millisecond,
		),
		pollInterval: envDuration(
			"READ_BARRIER_POLL_INTERVAL",
			10*time.Millisecond,
		),
	}

	checkCtx, cancel := context.WithTimeout(
		ctx,
		store.queryTimeout,
	)
	defer cancel()

	if err := store.validateRoles(checkCtx); err != nil {
		store.Close()
		return nil, err
	}

	return store, nil
}

func openPool(
	ctx context.Context,
	url string,
	applicationName string,
	readOnly bool,
) (*pgxpool.Pool, error) {
	cfg, err := pgxpool.ParseConfig(url)
	if err != nil {
		return nil, err
	}

	cfg.ConnConfig.RuntimeParams["application_name"] =
		applicationName

	if readOnly {
		cfg.ConnConfig.RuntimeParams[
			"default_transaction_read_only"
		] = "on"
	}

	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil {
		return nil, err
	}

	if err := pool.Ping(ctx); err != nil {
		pool.Close()
		return nil, err
	}

	return pool, nil
}

func (s *Store) Close() {
	s.reader.Close()
	s.writer.Close()
}

func (s *Store) validateRoles(ctx context.Context) error {
	var writerInRecovery bool
	if err := s.writer.QueryRow(
		ctx,
		`SELECT pg_is_in_recovery()`,
	).Scan(&writerInRecovery); err != nil {
		return fmt.Errorf("validate writer: %w", err)
	}

	if writerInRecovery {
		return errors.New("DATABASE_URL points to a standby")
	}

	var readerInRecovery bool
	if err := s.reader.QueryRow(
		ctx,
		`SELECT pg_is_in_recovery()`,
	).Scan(&readerInRecovery); err != nil {
		return fmt.Errorf("validate reader: %w", err)
	}

	if !readerInRecovery {
		return errors.New(
			"READER_DATABASE_URL does not point to a standby",
		)
	}

	return nil
}

func (s *Store) CreateOrder(
	ctx context.Context,
	requestID string,
	customerID int64,
	amountCents int64,
) (result WriteResult, err error) {
	conn, err := s.writer.Acquire(ctx)
	if err != nil {
		return result, fmt.Errorf("acquire writer: %w", err)
	}
	defer conn.Release()

	tx, err := conn.BeginTx(
		ctx,
		pgx.TxOptions{IsoLevel: pgx.ReadCommitted},
	)
	if err != nil {
		return result, fmt.Errorf("begin: %w", err)
	}

	defer func() {
		rollbackCtx, cancel := context.WithTimeout(
			context.Background(),
			s.queryTimeout,
		)
		defer cancel()

		_ = tx.Rollback(rollbackCtx)
	}()

	err = tx.QueryRow(ctx, `
		INSERT INTO orders (
			request_id,
			customer_id,
			amount_cents
		)
		VALUES ($1, $2, $3)
		ON CONFLICT (request_id) DO UPDATE
		SET request_id = EXCLUDED.request_id
		RETURNING
			id,
			request_id,
			customer_id,
			amount_cents,
			created_at
	`,
		requestID,
		customerID,
		amountCents,
	).Scan(
		&result.Order.ID,
		&result.Order.RequestID,
		&result.Order.CustomerID,
		&result.Order.AmountCents,
		&result.Order.CreatedAt,
	)
	if err != nil {
		return result, classify("insert order", err)
	}

	if err = tx.Commit(ctx); err != nil {
		result.Outcome = CommitUnknown

		if errors.Is(err, pgx.ErrTxCommitRollback) {
			result.Outcome = CommitRolledBack
		}

		return result, fmt.Errorf(
			"commit order (outcome=%s): %w",
			result.Outcome,
			err,
		)
	}

	result.Outcome = CommitCommitted

	// 在 Commit 成功后、同一 Writer Connection 上采样
	// 当前已经持久化的 WAL 位置。
	//
	// 该位置可能包含其他并发事务的 WAL,因此可能多等一段;
	// 但不会早于刚刚成功提交的事务。
	barrierCtx, cancel := context.WithTimeout(
		ctx,
		s.queryTimeout,
	)
	defer cancel()

	err = conn.QueryRow(
		barrierCtx,
		`SELECT pg_current_wal_flush_lsn()::text`,
	).Scan(&result.ReplayBarrier)
	if err != nil {
		return result, fmt.Errorf(
			"write committed but replay barrier could not be collected: %w",
			err,
		)
	}

	return result, nil
}

func (s *Store) GetOrderReadAfterWrite(
	ctx context.Context,
	orderID int64,
	requiredLSN string,
) (ReadResult, error) {
	if requiredLSN == "" {
		return s.readFromPrimary(
			ctx,
			orderID,
			"no replay barrier available",
		)
	}

	waitCtx, cancel := context.WithTimeout(
		ctx,
		s.barrierTimeout,
	)

	readerConn, err := s.reader.Acquire(waitCtx)
	if err == nil {
		err = waitForReplay(
			waitCtx,
			readerConn,
			requiredLSN,
			s.pollInterval,
		)

		if err == nil {
			queryCtx, queryCancel := context.WithTimeout(
				ctx,
				s.queryTimeout,
			)

			order, queryErr := fetchOrder(
				queryCtx,
				readerConn,
				orderID,
			)

			queryCancel()
			readerConn.Release()
			cancel()

			if queryErr == nil {
				return ReadResult{
					Order:  order,
					Source: "reader",
				}, nil
			}

			err = queryErr
		} else {
			readerConn.Release()
			cancel()
		}
	} else {
		cancel()
	}

	return s.readFromPrimary(
		ctx,
		orderID,
		fmt.Sprintf("reader barrier failed: %v", err),
	)
}

func waitForReplay(
	ctx context.Context,
	conn *pgxpool.Conn,
	requiredLSN string,
	poll time.Duration,
) error {
	ticker := time.NewTicker(poll)
	defer ticker.Stop()

	for {
		var ready bool

		err := conn.QueryRow(ctx, `
			SELECT pg_is_in_recovery()
			   AND COALESCE(
			       pg_last_wal_replay_lsn() >= $1::pg_lsn,
			       false
			   )
		`, requiredLSN).Scan(&ready)
		if err != nil {
			return classify("check replay barrier", err)
		}

		if ready {
			return nil
		}

		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-ticker.C:
		}
	}
}

type rowQuerier interface {
	QueryRow(
		context.Context,
		string,
		...any,
	) pgx.Row
}

func fetchOrder(
	ctx context.Context,
	q rowQuerier,
	orderID int64,
) (Order, error) {
	var order Order

	err := q.QueryRow(ctx, `
		SELECT
			id,
			request_id,
			customer_id,
			amount_cents,
			created_at
		FROM orders
		WHERE id = $1
	`, orderID).Scan(
		&order.ID,
		&order.RequestID,
		&order.CustomerID,
		&order.AmountCents,
		&order.CreatedAt,
	)
	if err != nil {
		return Order{}, classify("fetch order", err)
	}

	return order, nil
}

func (s *Store) readFromPrimary(
	ctx context.Context,
	orderID int64,
	reason string,
) (ReadResult, error) {
	queryCtx, cancel := context.WithTimeout(
		ctx,
		s.queryTimeout,
	)
	defer cancel()

	order, err := fetchOrder(
		queryCtx,
		s.writer,
		orderID,
	)
	if err != nil {
		return ReadResult{}, err
	}

	return ReadResult{
		Order:          order,
		Source:         "primary",
		FallbackReason: reason,
	}, nil
}

func classify(operation string, err error) error {
	var pgErr *pgconn.PgError

	if errors.As(err, &pgErr) {
		return fmt.Errorf(
			"%s failed: sqlstate=%s constraint=%s: %w",
			operation,
			pgErr.SQLState(),
			pgErr.ConstraintName,
			err,
		)
	}

	return fmt.Errorf("%s failed: %w", operation, err)
}

func runBounded(
	ctx context.Context,
	limit int,
	jobs []func(context.Context) error,
) error {
	if limit < 1 {
		return errors.New(
			"concurrency limit must be positive",
		)
	}

	sem := make(chan struct{}, limit)
	errCh := make(chan error, len(jobs))

	var wg sync.WaitGroup

	for _, job := range jobs {
		job := job

		select {
		case <-ctx.Done():
			errCh <- ctx.Err()
			goto wait
		case sem <- struct{}{}:
		}

		wg.Add(1)

		go func() {
			defer wg.Done()
			defer func() { <-sem }()

			if err := job(ctx); err != nil {
				errCh <- err
			}
		}()
	}

wait:
	wg.Wait()
	close(errCh)

	var errs []error
	for err := range errCh {
		errs = append(errs, err)
	}

	return errors.Join(errs...)
}

func envDuration(
	name string,
	fallback time.Duration,
) time.Duration {
	value := os.Getenv(name)
	if value == "" {
		return fallback
	}

	d, err := time.ParseDuration(value)
	if err != nil || d <= 0 {
		return fallback
	}

	return d
}

func envInt(name string, fallback int) int {
	value := os.Getenv(name)
	if value == "" {
		return fallback
	}

	n, err := strconv.Atoi(value)
	if err != nil || n <= 0 {
		return fallback
	}

	return n
}

pgxpool.NewWithConfigAcquireBeginTxPingClose 均属于当前 pgx/v5/pgxpool API。PostgreSQL 服务端错误应通过 errors.As 提取 *pgconn.PgError,再读取 SQLState(),不能依赖错误文本。(Go Packages)

14.3 实现中的关键语义

必须在 Commit 后取 Barrier

错误方式:

事务提交前读取 LSN
→ Commit WAL 可能位于该 LSN 之后
→ Replica 到达旧 LSN
→ 业务写入仍不可见

代码在 Commit 成功后读取 pg_current_wal_flush_lsn()。它可能包含其他并发事务的 WAL,因此可能“多等”,但不会比本事务 Commit WAL 更早。

Barrier 和读取必须绑定同一 Replica

Reader Pool 可能包含多个 Replica。若:

在 Replica A 检查 Replay
→ 从 Pool 重新取连接
→ 实际在 Replica B 读取

则 Replica B 可能尚未追上。

示例因此在同一个 pgxpool.Conn 上完成 Barrier 检查和读取。

超时回退 Primary

LSN Barrier 的目标是提高读扩展比例,不应把 Replica 故障转换成无限等待:

Replica 在预算内追上 → 从 Replica 读
Replica 未追上或报错 → 从 Primary 读

Primary 回退也必须有独立超时和并发限制,否则 Replica 故障会把全部读流量瞬间压向 Primary。

Commit 错误不等于未提交

  • pgx.ErrTxCommitRollback 表示事务实际按回滚结束。
  • 网络中断、超时等其他 Commit 错误可能使结果不确定。
  • 结果不确定时不能直接重试写事务。
  • 使用 request_id、唯一约束和业务查询确认结果。

Failover 后不能只携带裸 LSN

LSN Token 只在同一个复制历史和拓扑 Epoch 内可靠。Promotion 后 Timeline 分叉,数值 LSN 可能不足以描述因果位置。

生产 Token 应至少考虑:

cluster_id
+ topology_epoch / primary_generation
+ timeline-aware metadata
+ LSN

发生 Failover 时,应使旧 Epoch Token 失效或直接回退当前 Primary。


15. 生产排障 Runbook

15.1 首先确认角色和拓扑

在每个节点执行:

SELECT
    inet_server_addr(),
    inet_server_port(),
    pg_is_in_recovery(),
    current_setting('cluster_name', true);

确认:

  • 哪个节点真正可写;
  • 应用 Writer Endpoint 指向哪里;
  • Reader Endpoint 包含哪些节点;
  • 是否刚发生 Promotion;
  • 旧 Primary 是否已 Fence;
  • Timeline 是否发生变化。

15.2 查看核心指标

Primary:

SELECT *
FROM pg_stat_replication;

重点:

  • state
  • sync_state
  • sent_lsn
  • write_lsn
  • flush_lsn
  • replay_lsn
  • write_lag
  • flush_lag
  • replay_lag

Standby:

SELECT *
FROM pg_stat_wal_receiver;

SELECT
    pg_last_wal_receive_lsn(),
    pg_last_wal_replay_lsn(),
    pg_last_xact_replay_timestamp();

Slot:

SELECT
    slot_name,
    active,
    restart_lsn,
    wal_status,
    safe_wal_size,
    inactive_since,
    invalidation_reason
FROM pg_replication_slots;

Conflict:

SELECT *
FROM pg_stat_database_conflicts;

15.3 判断问题位于哪一段

现象优先判断
Primary current LSN 与 sent_lsn 差距大WAL Sender、Primary CPU、网络发送
sent_lsnwrite_lsn 差距大网络、Receiver、Standby WAL 写入
write_lsnflush_lsn 差距大Standby WAL 存储 Flush
flush_lsnreplay_lsn 差距大Replay CPU/I/O、Conflict、分析负载
LSN 接近但应用仍陈旧路由、旧 Snapshot、缓存、跨 Replica
Commit P99 暴涨同步等待、网络 RTT、Standby Flush/Replay
pg_wal 快速增长Slot、Archive 失败、Checkpoint/WAL 峰值
Replica 查询频繁取消Recovery Conflict、延迟参数、长 Snapshot
Writer Pool 耗尽同步 Commit 等待、锁链、数据库变慢

15.4 找 Blocker

普通锁:

SELECT
    a.pid,
    a.usename,
    a.application_name,
    a.xact_start,
    a.wait_event_type,
    a.wait_event,
    pg_blocking_pids(a.pid) AS blocking_pids,
    a.query
FROM pg_stat_activity AS a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0
ORDER BY a.xact_start;

同步复制等待:

SELECT
    pid,
    xact_start,
    wait_event_type,
    wait_event,
    query
FROM pg_stat_activity
WHERE wait_event = 'WaitForStandbyConfirmation'
ORDER BY xact_start;

后者的“阻塞者”通常不是另一个普通 Backend,而是尚未满足的 Standby 确认条件。

15.5 CPU、内存和 I/O

数据库侧:

SELECT *
FROM pg_stat_io
ORDER BY backend_type, object, context;

SELECT *
FROM pg_stat_recovery_prefetch;

操作系统侧同时检查:

iostat -xz 1
vmstat 1
pidstat -dur 1
ss -tin

关注:

  • Standby WAL 设备和数据设备是否共用一个饱和队列;
  • 临时文件是否占满分析 Replica 的磁盘带宽;
  • Startup Process 是否长期占用 CPU;
  • Receiver 是否等待网络;
  • 内存不足是否造成 Page Cache 抖动或 Swap。

15.6 连接池

应用记录:

  • Writer Pool 总连接、空闲连接、已获取连接;
  • Reader Pool 总连接、空闲连接、已获取连接;
  • Acquire Count;
  • Empty Acquire Count;
  • Acquire Duration;
  • 等待队列长度;
  • 超时和回退 Primary 次数。

Reader 故障时特别观察 Primary Pool 是否被回退流量打满。

15.7 WAL 与 Slot

估算 Slot 保留:

SELECT
    slot_name,
    active,
    pg_size_pretty(
        pg_wal_lsn_diff(
            pg_current_wal_lsn(),
            restart_lsn
        )::bigint
    ) AS retained,
    wal_status,
    pg_size_pretty(safe_wal_size)
FROM pg_replication_slots;

同时检查:

SELECT
    count(*) AS wal_files,
    pg_size_pretty(sum(size)) AS wal_size
FROM pg_ls_waldir();

pg_ls_waldir() 需要适当权限。

15.8 VACUUM 与 Conflict

Primary:

SELECT
    relname,
    n_live_tup,
    n_dead_tup,
    last_autovacuum,
    autovacuum_count
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;

Standby:

SELECT *
FROM pg_stat_database_conflicts
ORDER BY
    confl_snapshot
  + confl_lock
  + confl_bufferpin DESC;

如果开启 hot_standby_feedback,同时检查 Primary 表和索引膨胀,不要只庆祝 Conflict 下降。

15.9 执行计划问题

复制延迟有时由 Standby 上异常昂贵的分析查询间接造成。可使用 pg_stat_statements 找出:

  • 总执行时间高;
  • 临时文件多;
  • Buffer Read 多;
  • 并发高;
  • 执行时间突增的查询。

PostgreSQL 核心不会为每次执行永久保存完整 EXPLAIN。若没有提前启用计划采集或 auto_explain 日志,就无法可靠重建“最早一次估算错误”。生产系统应提前保存 Query ID、计划、实际行数和版本变更时间线。

15.10 在线低风险操作

通常可在线执行:

  • 查询统计视图;
  • 查看配置;
  • 取消单个明显失控的分析查询;
  • 调整应用读路由和准入;
  • 通过受控流程 Reload 可重载参数;
  • 临时将非关键读取回退 Primary,前提是有容量保护。

15.11 高风险操作

必须经过变更审批:

  • pg_promote()
  • 删除 Replication Slot;
  • 强制 Checkpoint 以触发 Slot 失效;
  • 直接修改 synchronous_standby_names
  • 将同步事务切为 local/off
  • 设置无限 Standby Delay;
  • pg_rewind
  • 删除 pg_wal 文件;
  • 未 Fence 的 Failover;
  • 把旧 Primary 直接重新开放写入。

绝不能手工删除 pg_wal 中的文件来“释放空间”。

15.12 临时止损

按场景选择:

  • 取消 Standby 上非关键长查询;
  • 限制分析查询并发;
  • Reader Lag 超阈值时从路由摘除;
  • 暂停低优先级写入以降低 WAL 速率;
  • 对 Reader 回退 Primary 设置比例和并发上限;
  • 修复 WAL Archive;
  • 恢复失联 Standby;
  • 经授权临时调整同步候选,而不是无限阻塞;
  • 废弃 Slot 前先确认对应消费者已永久下线。

15.13 根本修复

可能包括:

  • 将 HA Replica 与 BI Replica 分离;
  • 为 WAL 和数据文件提供更合适的存储;
  • 优化高 WAL 写入、索引数量和大事务;
  • 建立 Slot 生命周期管理;
  • 增加 WAL Archive 兜底;
  • 重新设计同步故障域和 Quorum;
  • 建立 LSN Read-After-Write;
  • 在 HA 控制面实现 Fencing;
  • 为 Commit 不确定结果建立幂等与对账。

15.14 验证修复

至少验证:

  1. 四阶段 LSN Gap 持续缩小或稳定在 SLO 内。
  2. Commit P95/P99 恢复。
  3. 同步等待数量归零或符合预期。
  4. Slot safe_wal_size 恢复安全区。
  5. pg_wal 不再异常增长。
  6. Recovery Conflict 速率下降。
  7. Primary 膨胀没有继续扩大。
  8. Writer/Reader Pool Acquire 延迟恢复。
  9. 业务 Read-After-Write 探针通过。
  10. Failover 候选可启动、可查询、可恢复。

15.15 告警建议

按基线和 SLO 设置,不使用全行业固定阈值:

  • Replica 不在 streaming 状态;
  • LSN 字节 Lag 超预算;
  • Replay 时间 Lag 超预算;
  • 同步 Standby 数量不足;
  • Commit 同步等待持续存在;
  • Slot wal_status 进入 unreserved/lost
  • safe_wal_size 低于预测故障窗口;
  • pg_wal 磁盘剩余空间不足;
  • inactive_since 超 Slot 生命周期;
  • Recovery Conflict 速率异常;
  • Reader Primary Fallback 比例上升;
  • Reader/Writer Pool Acquire P99 上升;
  • Timeline 或 Primary Generation 非预期变化。

16. 常见错误与反模式

  1. 把异步读副本当成强一致数据库。 写入后立刻读 Replica,却没有 Barrier 或 Primary 回退。

  2. 只看 replay_lag 一个时间字段。 忽略字节 Gap、Receiver 状态、Slot 和 Primary 空闲造成的时间误导。

  3. 给每个临时 Replica 永久创建无上限 Slot。 节点下线后 Slot 继续阻止 WAL 回收。

  4. 认为 max_slot_wal_keep_size 会自动保护 Standby。 它保护的是 Primary 磁盘,上限触发后 Standby 可能必须重建。

  5. 启用 hot_standby_feedback 后不监控 Bloat。 查询不再取消,但 Primary 表和索引持续膨胀。

  6. 在 HA Standby 上设置无限 Replay Delay。 分析查询稳定了,Failover 候选却越来越落后。

  7. 所有事务跨远距离使用 remote_apply 把 WAN 抖动、Replay 和分析负载全部放入 Commit P99。

  8. 把同步复制等同于备份。 误删和逻辑损坏会同步到所有节点。

  9. 同步 Standby 数量要求高于可用故障域。 一次维护就让全部写请求卡在 Commit。

  10. Promotion 前不做 Fencing。 形成两个可写节点和不可自动合并的 Timeline。

  11. 把旧 Primary 直接启动并加入写路由。 忽略 Timeline 分叉和 Split Brain。

  12. 在 Commit 前采集 Read-After-Write LSN。 Token 可能早于事务 Commit Record。

  13. 在 Replica A 检查 LSN,却从 Replica B 读取。 Pool 重新选连接后丢失一致性保证。

  14. Commit 超时就直接重试写事务。 可能造成重复扣款、重复订单或重复消息。

  15. Replica 故障时所有读请求无条件回退 Primary。 回退风暴会让原本健康的 Primary 也失效。

  16. 手工删除 pg_wal 文件释放磁盘。 可能直接破坏实例恢复能力。


17. 模拟生产事故案例

案例一:废弃 Slot 写满 Primary 磁盘

系统背景

  • Primary 每小时生成大量 WAL。
  • 有两个正常 Standby。
  • 一个月前下线了一台临时报表 Replica。
  • 对应 Physical Slot 未删除。
  • max_slot_wal_keep_size=-1

故障现象

  • pg_wal 持续增长。
  • 根文件系统剩余空间快速下降。
  • 数据文件大小并无明显增长。
  • 正常 Standby Lag 接近零。

错误假设

“既然两个 Standby 都追上了,WAL 应该会自动回收。”

排查过程

SELECT
    slot_name,
    active,
    restart_lsn,
    inactive_since,
    pg_size_pretty(
        pg_wal_lsn_diff(
            pg_current_wal_lsn(),
            restart_lsn
        )::bigint
    ) AS retained
FROM pg_replication_slots
ORDER BY
    pg_wal_lsn_diff(
        pg_current_wal_lsn(),
        restart_lsn
    ) DESC;

发现一个 active=false 的废弃 Slot 保留了数百 GB WAL。

根因

Slot 是持久状态,不会因为对应服务器被删除而自动消失。它仍声明旧 WAL 可能被消费者需要。

临时止损

  1. 确认该 Replica 已永久下线。
  2. 保存拓扑和变更证据。
  3. 删除废弃 Slot:
SELECT pg_drop_replication_slot('retired_bi_replica');
  1. 持续监控 Checkpoint 后 WAL 回收。
  2. 不手工删除 WAL 文件。

最终修复

  • 所有 Replica 生命周期操作绑定 Slot 创建和删除。
  • 设置 max_slot_wal_keep_size
  • [PG18] 对临时 Slot 设置合理 idle_replication_slot_timeout
  • inactive_sincesafe_wal_sizewal_status 和磁盘预测耗尽时间告警。
  • 使用 WAL Archive 作为追赶兜底。

防止复发

资产管理系统中的 Replica、Slot、DNS 和监控对象必须一一对应;节点下线流程未删除 Slot 时不得关闭工单。


案例二:remote_apply 与 BI 查询拖垮交易写入

系统背景

  • 一个 Primary,一个同步 Standby。
  • 全局 synchronous_commit=remote_apply
  • 同一 Standby 同时运行长时间 BI 查询。
  • max_standby_streaming_delay 设置较大。

故障现象

  • Primary CPU 正常。
  • Commit P99 从几十毫秒升至数秒。
  • 热点订单行锁等待增加。
  • Writer Pool Acquire Timeout。
  • Standby Replay Lag 持续增加。
  • WAL Receiver Flush LSN 正常前移。

错误假设

“Standby 已经收到并 Flush WAL,因此同步复制不可能影响 Commit。”

排查过程

Primary:

SELECT
    pid,
    xact_start,
    wait_event,
    query
FROM pg_stat_activity
WHERE wait_event = 'WaitForStandbyConfirmation';

SELECT
    flush_lsn,
    replay_lsn,
    pg_wal_lsn_diff(flush_lsn, replay_lsn)::bigint
FROM pg_stat_replication;

Standby:

SELECT
    pid,
    query_start,
    state,
    query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY query_start;

发现 WAL 已 Flush,但 Replay 被长分析查询和 I/O 竞争拖慢;remote_apply 使 Primary Commit 必须等待 Replay。

根因

把“同步 HA Replica”和“长查询分析 Replica”混为一个故障域与性能域。

临时止损

  • 取消非关键 BI 查询。
  • 对 BI 入口实施并发限制。
  • 经业务授权,仅对不要求 Replica 立即可见的事务调整同步级别。
  • 限制 Reader 回退 Primary 的并发。

最终修复

  • HA 同步 Standby 只承担受控短查询。
  • 新建独立异步 BI Replica。
  • 关键 Read-After-Write 使用 LSN Barrier,避免所有写事务都使用 remote_apply
  • 对 BI 查询设置超时、临时文件和并发预算。
  • 对 Replay Gap 和同步 Commit Wait 建立联合告警。

防止复发

容量评审同时检查:

  • Standby Replay 峰值能力;
  • BI 查询资源;
  • Commit P99;
  • Recovery Conflict;
  • 同步等待锁放大效应。

18. 面试题

18.1 核心概念题

1. 物理复制和逻辑复制的核心区别是什么?

**30 秒回答:**物理复制传输整个集群的 WAL 物理变化,适合同大版本 HA;逻辑复制传输表级逻辑变更,适合 CDC、选择性复制和跨版本迁移。

**深入回答:**物理复制的优点是完整、低侵入、可直接 Promotion;缺点是粒度粗、通常要求相同大版本。逻辑复制更灵活,但 DDL、Sequence、冲突和一致性边界更复杂。

**考察:**能否区分 HA 与数据集成。 常见误答:“物理复制更快,所以永远更好。” **追问:**跨大版本在线升级选什么? **追问答案:**通常选择逻辑复制或其他官方升级路径,不使用物理流复制跨大版本。

2. sent_lsnwrite_lsnflush_lsnreplay_lsn 分别是什么?

**30 秒回答:**分别表示已发送、已写入 Standby OS、已在 Standby 持久化、已在 Standby 应用。

**深入回答:**四者把 Lag 分解为 Sender、网络/Receiver、存储 Flush 和 Replay 四段。flush_lsn 追上不代表查询可见,查询可见需要 Replay 和新 Snapshot。

**考察:**是否真正理解复制流水线。 **常见误答:**把 write_lsn 当成持久化。 **追问:**哪一列对应 remote_apply? **追问答案:**提交等待语义主要对应 Standby Replay 进度。

3. Physical Replication Slot 的 restart_lsn 是什么?

**30 秒回答:**它是 Slot 消费者仍可能需要的最旧 WAL 位置,因此之前相关 WAL 不能正常回收。

**深入回答:**它不是当前 Replay LSN。消费者不活跃时,restart_lsn 可能长期不前移并填满 pg_wal。应监控 wal_statussafe_wal_size

**考察:**Slot 的保护与风险。 常见误答:“Slot 会把 WAL 传给 Replica。” **追问:**设置 max_slot_wal_keep_size 后是否绝对安全? **追问答案:**Primary 磁盘更安全,但 Slot 可能丢失所需 WAL,Replica 需要重建。

4. FIRSTANY 有什么区别?

30 秒回答:FIRST 按列表优先级选择指定数量,ANY 等待候选集合中的任意指定数量。

深入回答:FIRST 行为确定但容易被高优先级慢节点拖累;ANY 更适合相近节点 Quorum,但仍需设计真实故障域。

**考察:**同步拓扑设计。 **常见误答:**认为 ANY 2 是三个节点多数派选主。 **追问:**它能防止脑裂吗? **追问答案:**不能。它控制提交确认,不负责选主和 Fencing。

5. Timeline 是什么?

**30 秒回答:**Timeline 是一条独立 WAL 历史分支,Promotion 或 PITR 完成后会创建新的 Timeline。

**深入回答:**Timeline 防止新历史覆盖旧 WAL,并通过 History File 描述分叉点。旧 Primary 与新 Primary 分叉后不能直接合并。

**考察:**Failover 后数据历史。 **常见误答:**把 Timeline 当成时间戳。 **追问:**为什么裸 LSN Token 跨 Failover 不可靠? **追问答案:**相同数值 LSN 可能属于不同 Timeline 和历史分支。

18.2 原理与排障题

6. 为什么复制 Lag 为零,业务仍可能读到旧数据?

**30 秒回答:**可能读到另一台 Replica、使用旧 Snapshot、统计采样过期,或缓存与外部系统未更新。

**深入回答:**数据库因果读需在目标 Replica 上确认 Replay 到写后 Barrier,再取得新 Snapshot,并保持读取节点不变。

**考察:**数据库指标与业务一致性的区别。 常见误答:“LSN 相等就一定强一致。” **追问:**最简单的安全回退是什么? **追问答案:**Barrier 超时后读取当前 Primary。

7. 为什么 Standby 查询会被取消?

**30 秒回答:**因为它与必须应用的 WAL 操作冲突,Standby 只能延迟 Replay 或取消查询,不能撤销 Primary 已提交操作。

**深入回答:**常见冲突包括 VACUUM Cleanup、DDL Access Exclusive Lock、Database/Tablespace Drop 和 Buffer Pin。

**考察:**Recovery Conflict 与普通锁的区别。 常见误答:“Replica 上有死锁。” **追问:**无限增大 Delay 的风险? **追问答案:**Replay Lag、RPO 和 Failover 时间上升。

8. 为什么 hot_standby_feedback 会导致 Bloat?

**30 秒回答:**它通知 Primary 保留 Standby 查询仍可能看到的旧 Tuple,使 VACUUM 不能及时清理。

**深入回答:**收益是减少 Snapshot Cleanup Conflict,代价是 Primary Heap/Index 膨胀、Cache 命中下降和 WAL/I/O 增加。

**考察:**MVCC Horizon 传播。 常见误答:“反馈只影响网络。” **追问:**它能解决 DDL Conflict 吗? **追问答案:**不能。

9. 同步 Standby 失联为什么会拖垮整个写服务?

**30 秒回答:**要求同步确认的 Commit 等待,而事务锁和连接继续占用,最终形成锁队列和 Pool 耗尽。

**深入回答:**数据库等待向上放大为 goroutine 排队、超时和重试风暴。需要同步候选冗余、Admission Control 和受控降级。

**考察:**复制与并发的耦合。 常见误答:“PostgreSQL 会自动切异步。” **追问:**如何定位? **追问答案:**检查同步 Wait Event、sync_state、四阶段 LSN 和 Pool Acquire 延迟。

10. Slot 导致磁盘满,能否直接删除 Slot?

**30 秒回答:**必须先确认消费者身份和是否永久废弃。删除活跃或仍需恢复的 Slot 会使 Replica 失去所需 WAL。

**深入回答:**先检查 active_pidrestart_lsninactive_sincewal_status 和资产记录,再决定恢复消费者、调整上限、删除 Slot 或重建 Replica。

**考察:**止损安全性。 **常见误答:**直接删除最大的 Slot。 **追问:**为什么不能删 pg_wal 文件? **追问答案:**会破坏 Crash Recovery、复制和实例一致性。

11. Commit 返回超时,事务一定回滚了吗?

**30 秒回答:**不一定。服务端可能已经提交,只是客户端没有收到响应。

**深入回答:**网络中断、同步确认等待或 Failover 都可能造成结果不确定。应使用 Idempotency Key、唯一约束和状态查询确认,不盲目重试。

**考察:**分布式提交不确定性。 常见误答:“收到 Error 就重试。” **追问:**pgx 中如何分类服务端错误? 追问答案:errors.As*pgconn.PgError,读取 SQLSTATE;Commit 网络错误仍需单独判断结果。

18.3 架构设计题

12. 如何设计同城双 AZ、跨区域灾备?

**30 秒回答:**Primary 和同步 Standby 分布在同城独立 AZ,跨区域使用异步 Standby 和 WAL Archive。

**深入回答:**同城 RTT 满足 Commit SLO;跨区避免把 WAN 放入每次提交。必须有 Fencing、候选优先级、PITR 和演练。

**考察:**RPO、RTO、延迟和故障域。 **常见误答:**所有区域都 remote_apply。 **追问:**跨区 RPO 如何度量? **追问答案:**使用候选 Flush LSN 与 Primary Flush LSN 的差距,结合业务事务速率。

13. 如何设计写后立即读取?

**30 秒回答:**写成功后获取 LSN Barrier,在同一 Replica 上等待 Replay 到 Barrier,超时回退 Primary。

**深入回答:**Barrier 必须在 Commit 后获取;检查和读取必须绑定同一 Replica;Failover 后 Token 需带拓扑 Epoch 或失效。

**考察:**因果一致性。 **常见误答:**固定 Sleep 100ms。 **追问:**替代方案? **追问答案:**关键事务使用 remote_apply,或者在一定时间内粘住 Primary。

14. HA Replica 能否兼作 BI Replica?

**30 秒回答:**可以运行受控短查询,但不应无边界混用。

**深入回答:**长查询会争用 CPU/I/O、引发 Recovery Conflict,或通过 Feedback 造成 Primary Bloat。推荐独立分析 Replica。

**考察:**工作负载隔离。 常见误答:“Replica 是免费的计算资源。” **追问:**分析 Replica 是否应参与首选 Failover? **追问答案:**通常不应,除非其 Lag、配置和容量持续满足 HA SLO。

15. 设计一次零误操作的 Planned Switchover

**30 秒回答:**排空写入、确认目标 Replay 到最终 LSN、Fence 旧 Primary、Promote、更新路由、重建连接、Rewind 旧 Primary、验证业务。

**深入回答:**还要处理 Commit 不确定请求、旧连接、Timeline、Slot、配置差异和回滚条件。

**考察:**端到端 RTO,而不只是数据库命令。 **常见误答:**直接执行 pg_promote()。 **追问:**最关键的顺序约束是什么? **追问答案:**旧 Primary 必须停止接受写入或被可靠 Fence,才能安全开放新 Primary。


19. 练习与参考答案

19.1 理论题

题 1

Primary 当前 Flush LSN 为 0/A000000,某 Standby:

sent_lsn   = 0/9F00000
write_lsn  = 0/9E00000
flush_lsn  = 0/9D00000
replay_lsn = 0/9800000

哪一段最可能是主要瓶颈?

**参考答案:**应计算每段字节差。直观上 flush_lsnreplay_lsn 的差距最大,应优先检查 Startup Replay、Standby CPU/I/O、Recovery Conflict 和分析查询。但仍需实际用 pg_wal_lsn_diff 计算,不能只按十六进制字符串长度判断。

题 2

synchronous_standby_names='ANY 2 (s1,s2,s3)',只剩 s1 可用,synchronous_commit=on 的事务会怎样?

**参考答案:**写事务可执行并在 Primary 生成和持久化 WAL,但 Commit 无法获得两个 Standby 的确认,因此会等待。锁、连接和上游请求可能持续堆积,直到足够 Standby 恢复、配置被受控修改或请求被中断。

题 3

为何 remote_apply 不一定适合所有 Read-After-Write?

**参考答案:**它把所有使用该设置的写事务都绑定到 Standby Replay 延迟。若只有少数读需要因果一致性,应用侧 LSN Barrier 加超时回退通常能避免让全部写入承担 Replay P99。remote_apply 仍适合一致性优先且延迟可接受的关键链路。

题 4

wal_keep_size 和 Physical Slot 的主要区别是什么?

参考答案:wal_keep_size 是额外保留的最低量,不跟踪具体消费者,也不保证超过范围后的 WAL。Slot 根据消费者进度保留准确需要的 WAL,但消费者停止会导致无界堆积风险。Archive 可作为两者之外的追赶兜底。

题 5

为什么同步复制仍需要 PITR?

**参考答案:**同步复制会同步逻辑误删、错误更新和恶意操作,也不能覆盖所有节点同时损坏、错误 Failover、存储谎报和长期历史恢复。PITR 提供独立时间维度和恢复点。


19.2 实验题

题 6

修改实验二,使 Primary 持续每秒写入一批记录,绘制:

  • Receive LSN;
  • Replay LSN;
  • Receive-Replay Gap;
  • Standby 磁盘剩余空间。

参考答案:

  1. Standby 执行 pg_wal_replay_pause()
  2. Primary 使用有界脚本每秒提交固定批次。
  3. 每秒采集 pg_last_wal_receive_lsn()pg_last_wal_replay_lsn()
  4. 使用 pg_wal_lsn_diff 记录字节差。
  5. 同时采集文件系统空间。
  6. 达到预设安全阈值前执行 Resume。
  7. 验证 Gap 追平时间是否符合 RTO。

题 7

比较以下三种配置的 Commit P50/P95/P99:

local
on
remote_apply

参考答案:

必须记录:

  • PostgreSQL 版本和所有相关配置;
  • Primary/Standby 硬件;
  • RTT;
  • WAL 设备;
  • 事务大小;
  • 并发数;
  • 缓存冷热;
  • 测试时长;
  • WAL/s;
  • Replay Lag;
  • Wait Event。

预期顺序通常是 local 延迟最低,remote_apply 最高,但不能伪造固定数值。

题 8

启用和关闭 hot_standby_feedback,重复实验三,比较:

  • Conflict 次数;
  • Primary n_dead_tup
  • 表和索引大小;
  • Replay Lag。

**参考答案:**关闭 Feedback 时更容易出现 Cleanup Conflict;开启后 Conflict 可能减少,但 Primary Dead Tuple 和物理空间更可能增长。结果还受反馈上报间隔、查询 Snapshot 和 VACUUM 时机影响。


19.3 排障题

题 9

现象:

sent_lsn ≈ write_lsn ≈ flush_lsn
replay_lsn 明显落后
Standby CPU 95%
Conflict 没有增长

如何排查?

参考答案:

  1. 检查 Startup Process CPU。
  2. 检查 Standby 同时运行的查询及并发数。
  3. 检查数据文件随机 I/O、Buffer Cache 和 Page Cache。
  4. 查看 pg_stat_recovery_prefetchpg_stat_io
  5. 判断 Primary WAL 生成率是否长期高于 Replay 能力。
  6. 检查大事务、批量写、索引创建和 VACUUM WAL。
  7. 临时限制分析查询并发。
  8. 根本修复可能是提高 Standby 计算/I/O、降低 WAL、拆分分析 Replica。

题 10

现象:

所有 Replica Lag 很低
Primary Commit P99 很高
大量会话 wait_event=WaitForStandbyConfirmation

可能原因是什么?

参考答案:

  • 同步反馈状态上报或网络 RTT 抖动;
  • 某个被选中同步 Standby 的 Flush 尾延迟;
  • ANY/FIRST 所需同步数量暂时不足;
  • remote_apply 下 Replay 短暂卡顿;
  • 监控采样时 Lag 已追上,但事务曾经等待;
  • Standby 在 catchup,尚未成为可用同步候选。

应关联事务时间、网络和存储时序,而不是只看当前 Lag 快照。


19.4 系统设计题

题 11

设计一个订单系统:

  • Primary 在东京 AZ-A;
  • 东京 AZ-B、AZ-C 各一台 Standby;
  • 大阪一台灾备;
  • 订单创建要求同城单节点故障不丢数据;
  • 订单详情写后立即读;
  • 报表允许延迟 30 秒;
  • Commit P99 预算严格。

参考答案

推荐拓扑:

Tokyo AZ-A Primary
├─ Tokyo AZ-B HA Standby
├─ Tokyo AZ-C HA Standby
└─ Osaka DR Standby(异步)

同步配置可选:

synchronous_standby_names =
    'ANY 1 (tokyo_b, tokyo_c)'

含义:

  • 任一同城 AZ 持久化确认即可。
  • 同时失去两台东京 Standby 时,要求同步的写会等待。
  • 大阪不进入同步 Commit 路径,避免 WAN P99。

订单创建:

  • 使用 synchronous_commit=on
  • Commit 后生成 LSN Barrier。
  • 详情读取在同一东京 Reader 上等待 Replay。
  • Barrier 超时回 Primary。
  • Token 携带 Primary Generation。

报表:

  • 使用独立异步 Replica,避免与 HA Standby 混用。
  • 设置查询并发、超时和资源预算。
  • 超过 30 秒 Lag 时从报表路由摘除。

高可用:

  • HA 控制面必须 Fence 旧 Primary。
  • 选择 Flush LSN 最先进且故障域合适的节点。
  • Promotion 后重建连接池。
  • 旧 Primary 使用 pg_rewind 或 Base Backup。
  • 大阪承担区域级灾备,接受明确的异步 RPO。

备份:

  • 独立 WAL Archive;
  • Base Backup;
  • PITR;
  • 定期恢复演练。

20. 检查清单

  • 我能够解释 WAL Sender、WAL Receiver 和 Startup Process 的职责。
  • 我能够区分 receive、write、flush 和 replay LSN。
  • 我能够计算并解释复制流水线各阶段的字节 Lag。
  • 我能够说明异步复制可能丢失哪些已确认事务。
  • 我能够说明同步复制为何不等于所有故障下的绝对零损失。
  • 我能够解释同步 Standby 失联为何可能阻塞写请求。
  • 我能够选择 remote_writeonremote_applylocal
  • 我能够配置并解释 FIRSTANY
  • 我能够诊断 Physical Slot 导致的 WAL 堆积。
  • 我能够正确使用 [PG18] idle_replication_slot_timeout
  • 我能够复现 Replay 暂停和 Recovery Conflict。
  • 我能够分析 hot_standby_feedback 对 Conflict 与 Bloat 的取舍。
  • 我能够解释复制 Lag 为零为何不保证业务一致。
  • 我能够在 Go 中实现 Writer Pool、Reader Pool 和 LSN Barrier。
  • 我能够在 Barrier 超时后有界地回退 Primary。
  • 我不会因 Commit 返回错误就武断认定事务未提交。
  • 我能够执行安全的 Planned Switchover。
  • 我能够解释 Promotion 后的 Timeline 分叉。
  • 我能够使用 pg_rewind 或 Base Backup 重建旧 Primary。
  • 我能够把 Fencing、旧连接处理和业务验证纳入真实 RTO。
  • 我能够为 Lag、Slot、Conflict、同步等待和 Pool 建立告警。

21. 官方资料来源

  • PostgreSQL 18:Log-Shipping、Streaming Replication、Replication Slot、Cascading 和 Synchronous Replication。(PostgreSQL)
  • PostgreSQL 18:复制参数、wal_keep_sizemax_slot_wal_keep_sizeidle_replication_slot_timeout、Standby Delay 和 Feedback。(PostgreSQL)
  • PostgreSQL 18:synchronous_commit 各模式的精确定义。(PostgreSQL)
  • PostgreSQL 18:Hot Standby 与 Recovery Conflict。(PostgreSQL)
  • PostgreSQL 18:复制统计、WAL Receiver 和等待事件。(PostgreSQL)
  • PostgreSQL 18:Replication Slot 系统视图。(PostgreSQL)
  • PostgreSQL 18:Timeline 和 Timeline History。(PostgreSQL)
  • PostgreSQL 18:pg_basebackup --write-recovery-conf。(PostgreSQL)
  • PostgreSQL 18:pg_promote、Replay Pause/Resume。(PostgreSQL)
  • PostgreSQL 18:pg_rewind 前置条件与失败风险。(PostgreSQL)
  • PostgreSQL 18:AIO 能力和适用路径。(PostgreSQL)
  • pgx/v5:pgxpoolpgconn.PgError API。(Go Packages)